大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 24 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。本章節講述的是如何透過 framebuffer 使 WebGL 預先計算資料到 texture,並透過這些預計算的資料製作鏡面、陰影效果,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
有了 framebuffer 的幫助,我們可以動用 GPU 的力量事先運算,在正式繪製畫面時使用。繼鏡面完成之後,根據 Day 22 所說,另一個 framebuffer 的應用是陰影,接下來就來介紹如何製作出陰影效果
陰影的產生是因為物體表面到光源之間有其他物體而被遮住,為了得知有沒有被遮住,我們可以從光源出發『拍攝』一次場景,從上篇改用 twgl 時有提到 framebuffer 也可以包含深度資訊,實際繪製畫面時就可以利用深度資訊來得知是否在陰影下
但是目前的光源是平行光,這樣要怎麼拍攝?首先,利用 Day 11 的 Orthogonal 3D 投影,如果光線是直直往 +y 的方向與地面垂直倒是蠻容易想像的,不過如果不是的時候,那麼感覺拍攝的範圍就沒辦法很大(淡藍色區域為投影區域,藍色面為成像面):
筆者想到在矩陣運算中還有一個叫做 shear,可以把一個空間中的矩形轉換成平行四邊形,透過這個工具,可以使得投影區域為平行四邊形:
如果去看 twgl.createFramebufferInfo()
預設建立的 framebuffer 與 textures 組合,可以看到一個存放顏色的 texture,但是另一個存放深度資訊卻不是 texture,是一個叫做 WebGLRenderbuffer
的東西:
經過測試,WebGLRenderbuffer
無法當成 texture 使用,為了建立能放深度資訊的 texture,需要 WebGL extension WEBGL_depth_texture
,跟 Day 16 的 VAO 功能一樣,不是 WebGL spec 的一部分,幸好 WEBGL_depth_texture
在主流瀏覽器中都有支援,只是需要寫一點程式來啟用:
async function setup() {
const gl = canvas.getContext('webgl');
// ...
const webglDepthTexExt = gl.getExtension('WEBGL_depth_texture');
if (!webglDepthTexExt) {
throw new Error('Your browser does not support WebGL ext: WEBGL_depth_texture')
}
// ...
}
啟用後,建立 framebuffer-texture 時便可指定 texture 的格式為 gl.DEPTH_COMPONENT
存放深度資訊,筆者將此 framebuffer-texture 命名為 lightProjection
:
async function setup() {
// ...
framebuffers.lightProjection = twgl.createFramebufferInfo(gl, [{
attachmentPoint: gl.DEPTH_ATTACHMENT,
format: gl.DEPTH_COMPONENT,
}], 2048, 2048);
textures.lightProjection = framebuffers.lightProjection.attachments[0];
// ...
}
在拍攝深度時,顏色計算就變成多餘的,同時為了預覽深度照片的成像,因此建立了一個簡單的 fragment shader,待會會與現有的 vertexShaderSource
連結:
precision highp float;
varying float v_depth;
void main() {
gl_FragColor = vec4(v_depth, v_depth, v_depth, 1);
}
可以看到這個 fragment shader 需要 varying v_depth
,因此在 vertex shader 中輸出:
+varying float v_depth;
void main() {
// ...
+ v_depth = gl_Position.z / gl_Position.w * 0.5 + 0.5;
}
因為 gl_Position.z / gl_Position.w
clip space 中的範圍是 -1 ~ +1,因此 * 0.5 + 0.5
使之介於 0 ~ +1 用於顏色輸出,並且使用 twgl.createProgramInfo()
建立 depthProgramInfo
:
async function setup() {
// ...
+ const depthProgramInfo = twgl.createProgramInfo(gl,
+ [vertexShaderSource, depthFragmentShaderSource]
+ );
return {
gl,
- programInfo,
+ programInfo, depthProgramInfo,
// ...
}
}
現有的光線方向向量是由 state.lightRotationXY
所控制,根據產生程式:
const lightDirection = matrix4.transformVector(
matrix4.multiply(
matrix4.yRotate(state.lightRotationXY[1]),
matrix4.xRotate(state.lightRotationXY[0]),
),
[0, -1, 0, 1],
).slice(0, 3);
光線一開始向著 -y 方向,接著旋轉 x 軸 state.lightRotationXY[0]
以及 y 軸 state.lightRotationXY[1]
,場景物件放置在 xz 平面上,因此 shear 時使用的角度為旋轉 x 軸的 state.lightRotationXY[0]
,整個 transform 經過以下步驟:
matrix4.projection()
捕捉的正面看著 +z,需要先旋轉使之看著 -y,接著旋轉 y 軸 state.lightRotationXY[1]
,這兩個轉換就是 Day 13 的視角 transform,需要做反矩陣matrix4.projection()
捕捉的正面看著 +z,依據角度偏移 y 值:y' = y + z * tan(state.lightRotationXY[0])
matrix4.projection()
進行投影,捕捉場景中 xz 介於 0 ~ 20,y (深度)介於 0 ~ 10 的物件matrix4.projection()
會把原點偏移到左上,透過 matrix4.translate(1, -1, 0)
轉換回來,最後捕捉場景中 xz 介於 -10 ~ +10,y 介於 -5 ~ +5 的物件把這些 transform 通通融合進 lightProjectionViewMatrix
:
function render(app) {
// ...
const lightProjectionViewMatrix = matrix4.multiply(
matrix4.translate(1, -1, 0),
matrix4.projection(20, 20, 10),
[ // shearing
1, 0, 0, 0,
0, 1, 0, 0,
0, Math.tan(state.lightRotationXY[0]), 1, 0,
0, 0, 0, 1,
],
matrix4.inverse(
matrix4.multiply(
matrix4.yRotate(state.lightRotationXY[1]),
matrix4.xRotate(degToRad(90)),
)
),
);
// ...
}
因為現在有多個 program,得在 renderBall()
以及 renderGround()
時指定使用的 program,因此加入 programInfo
參數到這兩個 function
-function renderBall(app, viewMatrix) {
- const { gl, programInfo, textures, objects } = app;
+function renderBall(app, viewMatrix, programInfo) {
+ const { gl, textures, objects } = app;
// ...
}
-function renderGround(app, viewMatrix, mirrorViewMatrix) {
- const { gl, programInfo, textures, objects } = app;
+function renderGround(app, viewMatrix, mirrorViewMatrix, programInfo) {
+ const { gl, textures, objects } = app;
// ...
}
並且修改現有渲染到畫面上的流程使用 depthProgramInfo
以及 lightProjectionViewMatrix
:
function render(app) {
const {
gl,
framebuffers,
- programInfo,
+ programInfo, depthProgramInfo,
state,
} = app;
twgl.bindFramebufferInfo(gl, framebuffers.mirror);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
- renderBall(app, mirrorViewMatrix);
+ renderBall(app, mirrorViewMatrix, programInfo);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// ...
- renderBall(app, viewMatrix);
- renderGround(app, viewMatrix, mirrorViewMatrix);
+ gl.useProgram(depthProgramInfo.program);
+
+ renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
+ renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
}
我們就獲得了灰階的深度視覺化:
至於回到正式『畫』時使用這些資訊繪製陰影的部份,將在下篇繼續實做,本篇的完整程式碼可以在這邊找到: